SSR Hydration 불일치가 생기는 이유
SSR Hydration 불일치가 생기는 이유
Hydration은 서버가 만든 HTML을 버리고 새로 그리는 과정이 아니라, 같은 React 트리가 만든 동일한 첫 화면에 이벤트와 상태를 연결하는 과정이다. 현재 시간, 난수, 브라우저 저장소, locale처럼 실행 환경에 따라 달라지는 값을 초기 렌더에서 바로 사용하면 이 전제가 깨진다. 서버가 사용한 스냅샷을 클라이언트에도 전달하거나, 동일한 fallback으로 시작한 뒤 hydration 이후에만 값을 바꾸는 방식으로 해결해야 한다.
SSR을 적용한 화면에서 가끔 다음과 비슷한 경고를 만난다.
Hydration failed because the server rendered HTML
didn't match the client.
페이지가 얼핏 정상적으로 보이면 경고만 숨기고 넘어가기 쉽다. 하지만 hydration 불일치는 단순한 콘솔 소음이 아니다. React가 기존 DOM을 재사용하지 못해 일부 트리를 클라이언트에서 다시 만들 수 있고, 초기 화면이 갑자기 바뀌거나 이벤트가 예상하지 못한 요소에 연결되는 문제로 이어질 수 있다.
이 문제를 제대로 해결하려면 “클라이언트 전용 코드가 섞였다”는 막연한 설명보다 다음 두 결과를 직접 비교해야 한다.
- 서버가 응답으로 보낸 HTML
- 브라우저에서 React가 수행한 첫 번째 렌더 결과
두 결과가 같아야 한다. useEffect가 실행된 이후의 두 번째 화면은 비교 대상이 아니다.
목차
- #Hydration은 HTML에 동작을 연결하는 과정이다
- #불일치가 실제 문제인 이유
- #원인을 세 종류로 나누기
- #시간과 locale을 안정적으로 렌더링하기
- #localStorage와 브라우저 API 다루기
- #난수와 ID를 렌더 중에 만들지 않기
- #데이터 스냅샷을 서버와 클라이언트가 공유하기
- #잘못된 HTML 구조와 외부 DOM 변경 찾기
- #클라이언트 전용 렌더링이 필요한 경우
- #suppressHydrationWarning은 해결책이 아니다
- #문제를 재현하고 추적하는 순서
- #선택 기준과 체크리스트
- #마무리
- #관련 노트
- #참고 자료
Hydration은 HTML에 동작을 연결하는 과정이다
서버 렌더링에서는 서버가 React 트리를 HTML로 만들고 브라우저에 전달한다. 사용자는 JavaScript가 모두 내려오기 전에도 화면의 내용을 볼 수 있다. 이후 브라우저는 같은 컴포넌트 트리를 실행하고 기존 HTML에 이벤트 핸들러와 상태를 연결한다. 이 과정이 hydration이다.
sequenceDiagram
participant S as 서버
participant B as 브라우저
participant R as React
S->>S: React 트리를 HTML로 렌더
S-->>B: HTML과 초기 데이터 전송
B->>B: HTML을 먼저 표시
B->>R: JavaScript 로드
R->>R: 같은 트리로 첫 렌더
R->>B: 기존 DOM에 이벤트와 상태 연결핵심은 서버와 브라우저가 “비슷한 UI”를 만들면 되는 것이 아니라 초기 출력이 같아야 한다는 점이다.
function Greeting() {
return <h1>안녕하세요</h1>;
}
서버도 <h1>안녕하세요</h1>를 만들고 브라우저의 첫 렌더도 같은 결과를 만들면 React는 기존 DOM을 신뢰하고 연결할 수 있다.
반면 다음 컴포넌트는 실행할 때마다 결과가 달라진다.
function RequestId() {
return <code>{Math.random()}</code>;
}
서버가 0.21을 렌더하고 브라우저가 0.78을 렌더한다면 동일한 트리라는 전제가 깨진다. 코드는 같지만 입력이 안정적이지 않은 것이다.
서버 HTML과 비교되는 것은 브라우저의 첫 렌더다. mount 후 useEffect에서 상태가 바뀌어 UI가 달라지는 것은 일반적인 업데이트이며 hydration 불일치가 아니다.
불일치가 실제 문제인 이유
React는 개발 환경에서 hydration 불일치를 경고한다. 일부 차이에서는 자동으로 복구할 수 있지만, 모든 속성 차이를 완전히 검증하고 고쳐 준다고 기대하면 안 된다. 전체 마크업 검증은 정상적인 앱에 불필요한 비용을 만들기 때문이다.
불일치가 남으면 다음 현상이 생길 수 있다.
- 서버에서 보이던 텍스트가 JavaScript 로드 직후 바뀐다.
- React가 해당 하위 트리를 버리고 클라이언트 렌더링으로 다시 만든다.
- 입력 중이던 값이나 선택 상태가 사라질 수 있다.
- DOM 구조 차이 때문에 이벤트가 예상과 다르게 연결될 수 있다.
- 재생성 비용으로 상호작용 가능 시점이 늦어진다.
- 실제 버그가 반복되는 경고에 묻혀 발견하기 어려워진다.
특히 느린 네트워크에서는 서버 화면이 몇 초 동안 보인 뒤 다른 내용으로 바뀔 수 있다. 개발자의 로컬 환경에서는 순식간이라 보이지 않던 깜빡임이 사용자에게는 명확하게 보인다.
모든 브라우저 전용 값을 useEffect로 옮기면 mismatch는 피할 수 있지만, hydration 직후 화면이 한 번 더 바뀐다. 해결책을 고를 때는 정확성뿐 아니라 빈 화면, 레이아웃 이동, 느린 연결에서의 경험을 함께 봐야 한다.
원인을 세 종류로 나누기
Hydration 문제는 원인을 다음 세 범주로 나누면 추적하기 쉬워진다.
| 범주 | 대표 원인 | 서버와 브라우저가 달라지는 이유 | 주된 해결 방향 |
|---|---|---|---|
| 입력 값 차이 | 시간, 난수, 변경 중인 API 데이터 | 두 환경이 서로 다른 순간·스냅샷을 사용 | 서버 스냅샷을 직렬화해 공유 |
| 실행 환경 차이 | window, localStorage, locale, timezone |
서버에는 브라우저 정보가 없거나 기본값이 다름 | 안정된 fallback 후 mount 갱신, 또는 요청 정보 전달 |
| 마크업 해석 차이 | 잘못 중첩한 태그, 브라우저 확장, CDN 변환 | 브라우저가 React 실행 전에 DOM을 수정 | 유효한 HTML 작성, 외부 변환 제거 |
실무에서는 원인이 겹치기도 한다. 예를 들어 서버는 UTC로 날짜를 렌더하고 브라우저는 사용자 locale과 timezone으로 렌더한다. 이는 시간이라는 입력 차이이면서 실행 환경 차이이기도 하다.
“SSR이라 생기는 버그”로 묶지 말고 어떤 값 또는 DOM이 어느 단계에서 달라졌는지를 찾는 것이 중요하다.
시간과 locale을 안정적으로 렌더링하기
현재 시각을 렌더 중에 직접 만들면 서버 렌더와 브라우저 렌더 사이의 몇 밀리초만으로도 결과가 달라질 수 있다.
function Clock() {
return <time>{new Date().toISOString()}</time>;
}
toISOString()은 timezone 차이는 없지만 호출 시점이 다르다. toLocaleString()을 사용하면 호출 시점뿐 아니라 서버와 브라우저의 locale·timezone 차이까지 추가된다.
가장 단순한 해결은 서버에서 만든 값을 props로 전달해 첫 렌더의 입력을 고정하는 것이다.
type ClockProps = {
initialIso: string;
};
export function Clock({ initialIso }: ClockProps) {
return <time dateTime={initialIso}>{initialIso}</time>;
}
export default function Page() {
const initialIso = new Date().toISOString();
return <Clock initialIso={initialIso} />;
}
실시간으로 갱신해야 한다면 같은 값으로 시작한 뒤 mount 후 타이머를 연결한다.
"use client";
import { useEffect, useState } from "react";
type LiveClockProps = {
initialIso: string;
};
export function LiveClock({ initialIso }: LiveClockProps) {
const [iso, setIso] = useState(initialIso);
useEffect(() => {
const update = () => setIso(new Date().toISOString());
update();
const timerId = window.setInterval(update, 1_000);
return () => window.clearInterval(timerId);
}, []);
return <time dateTime={iso}>{iso}</time>;
}
이 경우 서버 HTML과 브라우저 첫 렌더는 initialIso로 같다. Effect가 실행된 뒤 현재 시각으로 갱신되는 것은 정상적인 상태 변경이다.
사용자 timezone으로 표시하려면 선택지가 두 가지다.
요청 시 timezone을 알 수 있는 경우
쿠키나 사용자 프로필에 timezone이 저장되어 있다면 서버와 클라이언트가 같은 명시적 옵션으로 포맷할 수 있다.
const formatter = new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Seoul",
});
locale과 timezone을 런타임 기본값에 맡기지 않고 입력으로 고정한다.
서버가 사용자 timezone을 모르는 경우
서버에서는 기계가 읽을 수 있는 UTC 값이나 자리표시자를 렌더하고 mount 후 현지 형식으로 바꾼다.
"use client";
import { useEffect, useState } from "react";
export function LocalDateTime({ iso }: { iso: string }) {
const [label, setLabel] = useState(iso);
useEffect(() => {
setLabel(
new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(iso)),
);
}, [iso]);
return <time dateTime={iso}>{label}</time>;
}
다만 ISO 문자열이 잠깐 보였다가 현지 문자열로 바뀌는 경험이 거슬릴 수 있다. 일정한 크기의 placeholder를 사용하거나, 서버에서도 이해 가능한 형식을 먼저 보여 주는 등 제품 요구에 맞게 선택해야 한다.
localStorage와 브라우저 API 다루기
테마처럼 localStorage에 저장된 값을 렌더 중에 읽는 코드도 흔한 원인이다.
"use client";
function ThemeLabel() {
const theme =
typeof window === "undefined"
? "light"
: localStorage.getItem("theme") ?? "light";
return <span>{theme}</span>;
}
서버에는 window가 없어 light를 렌더하지만 브라우저 저장소에는 dark가 있을 수 있다. typeof window !== "undefined" 검사는 서버 오류를 막았을 뿐, 출력의 일치까지 보장하지 않는다.
안정된 기본값으로 시작하고 mount 후 읽으면 mismatch는 사라진다.
"use client";
import { useEffect, useState } from "react";
type Theme = "light" | "dark";
export function ThemeLabel() {
const [theme, setTheme] = useState<Theme>("light");
useEffect(() => {
const stored = window.localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
setTheme(stored);
}
}, []);
return <span>{theme}</span>;
}
하지만 다크 모드에서는 밝은 화면이 잠깐 보이는 FOUC가 생길 수 있다. 이 문제는 단순히 hydration 경고만 피해서는 해결되지 않는다. 가능한 대안은 다음과 같다.
- 테마를 쿠키에도 저장해 서버가 초기 테마를 알게 한다.
- 첫 paint 전에 실행되는 작은 스크립트가
<html>의 클래스를 정한다. - CSS의
prefers-color-scheme을 기본값으로 활용한다. - 테마 텍스트처럼 중요하지 않은 부분만 mount 후 표시한다.
서버가 쿠키에서 초기 테마를 읽는 구조라면 첫 렌더 입력을 공유할 수 있다.
type ThemeShellProps = {
initialTheme: "light" | "dark";
};
export function ThemeShell({
initialTheme,
children,
}: React.PropsWithChildren<ThemeShellProps>) {
return (
<div data-theme={initialTheme}>
{children}
</div>
);
}
브라우저 전용 API를 만났을 때 무조건 Effect로 미루기보다 “요청 시 서버도 이 값을 알 수 있게 할 것인가?”, “첫 화면에서는 안정된 fallback으로 충분한가?”를 먼저 결정해야 한다.
난수와 ID를 렌더 중에 만들지 않기
다음처럼 렌더 중 난수로 ID를 만들면 서버와 클라이언트의 연결 관계가 달라질 수 있다.
function EmailField() {
const id = `email-${Math.random()}`;
return (
<>
<label htmlFor={id}>이메일</label>
<input id={id} name="email" />
</>
);
}
접근성 속성에 필요한 안정적인 ID라면 React의 useId를 사용한다.
import { useId } from "react";
export function EmailField() {
const id = useId();
return (
<>
<label htmlFor={id}>이메일</label>
<input id={id} name="email" type="email" />
</>
);
}
useId는 서버 렌더링과 hydration에서 컴포넌트 트리가 같다는 전제 아래 안정적인 ID를 만든다. 목록 데이터의 key를 만드는 용도는 아니다. 데이터 항목의 key는 데이터베이스 ID처럼 항목 자체에 속한 식별자를 사용해야 한다.
랜덤 값이 제품 요구상 필요하다면 생성 시점을 분리한다.
- 서버에서 한 번 만들고 props로 전달한다.
- 사용자 액션이 발생한 시점에 만든다.
- mount 후 생성하고 초기 렌더에는 고정된 placeholder를 쓴다.
렌더 함수는 같은 입력에 같은 출력을 만드는 쪽이 hydration뿐 아니라 테스트와 재렌더링 예측에도 유리하다.
데이터 스냅샷을 서버와 클라이언트가 공유하기
서버가 HTML을 만들 때 조회한 데이터와 브라우저 첫 렌더가 다시 조회한 데이터가 다르면 mismatch가 생길 수 있다.
// 개념적으로 문제가 되는 흐름
const serverTodos = await fetchTodos(); // 3개
// 응답 전송 사이에 새 todo 추가
const clientTodos = await fetchTodos(); // 4개
초기 화면에서는 서버가 사용한 스냅샷을 클라이언트에도 전달해야 한다.
type Todo = {
id: string;
title: string;
};
export default async function TodoPage() {
const todos = await fetchTodos();
return <TodoList initialTodos={todos} />;
}
"use client";
import { useState } from "react";
export function TodoList({
initialTodos,
}: {
initialTodos: Todo[];
}) {
const [todos, setTodos] = useState(initialTodos);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
서버 상태 라이브러리를 사용한다면 서버에서 채운 캐시를 클라이언트가 hydrate하도록 직렬화하는 방법을 사용할 수 있다. 중요한 것은 도구 이름이 아니라 동일한 첫 스냅샷이라는 원칙이다.
첫 렌더가 끝난 뒤 revalidation으로 최신 데이터를 가져오는 것은 괜찮다. 서버 HTML을 만들 때 사용한 데이터와 브라우저 첫 렌더의 입력만 같으면 된다.
캐시를 공유할 때 Date, Map, 클래스 인스턴스처럼 직렬화 방식이 다른 값도 주의한다. JSON으로 전달할 수 있는 명시적인 DTO로 변환하면 경계가 단순해진다.
잘못된 HTML 구조와 외부 DOM 변경 찾기
값이 같아도 HTML 구조가 유효하지 않으면 브라우저 파서가 DOM을 교정한다.
// 잘못된 중첩
export function Notice() {
return (
<p>
안내 문구
<div>추가 설명</div>
</p>
);
}
브라우저는 <p> 안의 <div>를 그대로 유지하지 않고 태그를 닫는 방식으로 DOM을 재구성할 수 있다. React가 기대한 트리와 실제 DOM이 달라져 hydration 오류가 난다.
// 의미와 구조를 맞춘 형태
export function Notice() {
return (
<section>
<p>안내 문구</p>
<div>추가 설명</div>
</section>
);
}
다음 중첩도 점검한다.
<button>안의 또 다른<button><a>안의 또 다른<a><p>안의<div>,<ul>,<ol>- 테이블 요소의 잘못된 계층
- 조건부 렌더링으로 서버와 클라이언트의 태그 종류가 바뀌는 경우
코드가 올바른데 특정 사용자에게만 발생한다면 React가 실행되기 전 DOM을 바꾸는 요소도 의심한다.
- 전화번호와 이메일을 링크로 바꾸는 모바일 브라우저 기능
- 광고 차단기, 번역기, 비밀번호 관리자 같은 확장 프로그램
- HTML을 재작성하는 CDN 최적화
- 설정이 맞지 않은 CSS-in-JS의 style 태그 순서
- 서버 응답 뒤에 HTML을 삽입하는 외부 스크립트
시크릿 창과 확장 프로그램을 끈 브라우저에서 비교하고, Network 탭의 원본 응답과 Elements 탭의 현재 DOM을 따로 보는 이유다.
클라이언트 전용 렌더링이 필요한 경우
에디터, 캔버스, 브라우저 SDK처럼 SSR 자체가 의미 없거나 서버에서 실행할 수 없는 컴포넌트도 있다. Next.js에서는 해당 경계를 client-only로 분리할 수 있다.
"use client";
import dynamic from "next/dynamic";
const BrowserEditor = dynamic(
() => import("./browser-editor"),
{
ssr: false,
loading: () => <EditorSkeleton />,
},
);
export function EditorSection() {
return <BrowserEditor />;
}
이는 hydration 문제를 원인 분석 없이 없애는 만능 옵션이 아니다. 서버 HTML이 사라지는 만큼 다음 비용이 생긴다.
- JavaScript가 로드될 때까지 실제 콘텐츠를 볼 수 없다.
- 검색 엔진이나 링크 미리보기에 필요한 내용이 누락될 수 있다.
- skeleton과 실제 UI 크기가 다르면 레이아웃이 이동한다.
- 낮은 성능의 기기에서 상호작용 가능 시점이 늦어진다.
따라서 페이지 전체가 아니라 서버 렌더링 가치가 낮은 작은 경계에만 적용한다. 현재 Next.js 버전에서 ssr: false를 사용하는 위치와 Server Component 제약도 공식 문서로 다시 확인한다.
suppressHydrationWarning은 해결책이 아니다
시간처럼 피하기 어려운 단일 텍스트 차이에는 suppressHydrationWarning을 사용할 수 있다.
export function GeneratedAt() {
return (
<time suppressHydrationWarning>
{new Date().toLocaleString()}
</time>
);
}
하지만 이 속성은 경고를 숨기는 탈출구다.
- 한 단계 깊이의 차이에만 적용된다.
- 구조 전체의 불일치를 해결하지 않는다.
- React가 불일치한 텍스트를 반드시 패치해 주는 것도 아니다.
- 잘못된 데이터 흐름이나 유효하지 않은 HTML을 고치지 않는다.
사용 전에는 다음 질문에 답해야 한다.
- 차이가 의도적이고 단일 요소로 제한되는가?
- 사용자에게 잠깐 다른 값이 보여도 괜찮은가?
- 동일한 초기 스냅샷을 전달하는 편이 더 정확하지 않은가?
- 해당 위치 아래에 상호작용 요소가 없는가?
원인을 설명할 수 없는 상태에서 붙이면 실제 회귀를 감추는 결과가 된다.
문제를 재현하고 추적하는 순서
Hydration 경고가 발생하면 다음 순서로 범위를 좁힌다.
1. 개발 모드의 컴포넌트 스택을 읽는다
경고에 표시된 태그와 컴포넌트 경로를 시작점으로 삼는다. 오류가 표시된 요소가 원인 자체가 아니라 상위 조건부 렌더의 결과일 수도 있으므로 부모 트리까지 확인한다.
2. 렌더 중 비결정적 입력을 검색한다
rg "Date\\.now|new Date|Math\\.random|localStorage|sessionStorage|typeof window" src app
검색 결과가 모두 문제는 아니지만 렌더 본문과 state 초기화 함수에서 사용되는 위치를 우선 본다.
3. 서버 응답과 현재 DOM을 분리해서 본다
페이지 소스 또는 Network 탭의 document response는 서버가 보낸 HTML에 가깝다. Elements 탭은 브라우저 파싱, 확장 프로그램, React 실행 이후의 DOM이다. 둘을 같은 것으로 착각하지 않는다.
4. JavaScript 실행을 늦춰 본다
네트워크와 CPU를 느리게 설정하면 서버 화면과 hydration 이후 화면의 차이가 눈에 잘 보인다. 깜빡임과 레이아웃 이동도 함께 기록한다.
5. 외부 요인을 제거한다
시크릿 창, 확장 프로그램 비활성화, CDN 우회 환경에서 재현한다. 특정 브라우저에서만 발생한다면 자동 링크 변환 같은 기능도 확인한다.
6. 원인별로 가장 작은 경계를 수정한다
페이지 전체 SSR을 끄기 전에 시간 표시, 테마 라벨, 브라우저 SDK 등 불안정한 부분을 작은 컴포넌트로 격리한다.
프레임워크를 직접 구성하는 환경이라면 hydrateRoot의 onRecoverableError로 복구 가능한 오류를 수집할 수 있다.
hydrateRoot(container, <App />, {
onRecoverableError(error, errorInfo) {
reportHydrationError({
message: error.message,
componentStack: errorInfo.componentStack,
});
},
});
Next.js처럼 root를 프레임워크가 관리한다면 직접 옵션을 넣기보다 프레임워크가 제공하는 개발 오버레이와 관측 방법을 사용한다.
선택 기준과 체크리스트
원인에 따라 해법이 달라진다.
| 상황 | 우선 선택 | 주의할 점 |
|---|---|---|
| 서버도 값을 알 수 있음 | 서버 값을 props로 전달 | 직렬화 가능한 DTO 사용 |
| 브라우저에서만 값을 알 수 있음 | 동일 fallback 후 Effect 갱신 | 두 번째 렌더와 화면 전환 비용 |
| SSR 가치가 없는 브라우저 SDK | 작은 경계만 SSR 비활성화 | 빈 화면과 JavaScript 비용 |
| 의도적인 단일 텍스트 차이 | 제한적으로 경고 억제 | 구조 문제를 숨기지 않기 |
| 실시간 외부 데이터 | 서버 스냅샷 공유 후 재검증 | 첫 렌더 전에 별도 재조회하지 않기 |
| 잘못된 태그 중첩 | 시맨틱 HTML로 구조 수정 | CSS로만 모양을 맞추지 않기 |
마무리
Hydration 불일치는 서버 렌더링의 우연한 부작용이 아니라, 서버 HTML과 브라우저 첫 렌더가 같은 스냅샷이어야 한다는 계약이 깨졌다는 신호다.
해결의 출발점은 값을 무조건 클라이언트로 미루는 것이 아니다. 서버도 알 수 있는 값은 서버에서 한 번 정하고 클라이언트에 전달한다. 브라우저만 알 수 있는 값은 안정된 fallback으로 시작한 뒤 mount 후 갱신한다. SSR 가치가 전혀 없는 작은 기능만 client-only 경계로 격리한다. 유효하지 않은 HTML이나 외부 DOM 변형은 데이터 문제와 별도로 추적한다.
서버 렌더 결과와 브라우저의 첫 렌더 결과를 같은 입력으로 만들고, 차이가 필요한 시점은 hydration 이후의 명시적인 상태 업데이트로 분리한다. 이 원칙을 지키면 경고뿐 아니라 첫 화면의 깜빡임과 예측하기 어려운 DOM 복구도 함께 줄일 수 있다.
관련 노트
- 30장 Date
- React 렌더링과 재렌더링의 차이
- useEffect 의존성 배열을 거짓말하면 생기는 문제
- 리스트 key에 index를 쓰면 생기는 문제
- Next.js Server Component와 Client Component 경계
- 서버 전용 환경 변수를 클라이언트에서 숨기기